[Terraform]ECS FargateでFireLensを使って複数サービスにログ出力する
こんにちは!コンサル部のinomaso(@inomasosan)です。
ECS FargateでFireLensからCloudWatch LogsやKinesis Data Firehoseへのログ出力を、Terraformでコード化したので紹介します。
検証にあたり、弊社の以下ブログを参考にしました。
そもそもFireLensって何?
FireLensは、複数のAWSサービスやAWSパートナーネットワーク(Datadog等)にログ出力することができます。 ECSタスク定義でサイドカーとして起動し、他のコンテナからログドライバーとして使用します。
コンテナイメージにはFluentdとFluent Bitを選択可能です。
今回の検証では、リソース使用率が低く、ログルータに推奨されているFluent Bitを使用します。
2021/11/6時点でFluent Bitでは、以下のAWSサービスにログを出力することができます。
- Amazon CloudWatch
- Amazon Kinesis Data Firehose
- Amazon Kinesis Data Streams
- Amazon S3
FireLens(Fluent Bit)の新旧プラグインに注意
FireLens(Fluent Bit)のプラグインにはC言語のプラグインと、GO言語のプラグインがあります。
新プラグインはC言語のプラグインの方なので、S3以外の対応するAWSサービスはプラグイン指定に注意が必要です。
プラグイン指定はECSタスク定義か、Fluentd又はFluent Bitの設定ファイルのName
に設定します。
新旧プラグインの指定方法の比較表は以下の通りです。
サービス | 新プラグイン | 旧プラグイン |
---|---|---|
Amazon CloudWatch | cloudwatch_logs | cloudwatch |
Amazon Kinesis Data Firehose | kinesis_firehose | firehose |
Amazon Kinesis Data Streams | kinesis_streams | kinesis |
Fluent BitのオフィシャルマニュアルにあるAmazon CloudWatchには、以下のような記載があるので、新規構築する場合は基本的に新プラグインを選択して頂くのが良いかと思います。
The Golang plugin was named cloudwatch; this new high performance CloudWatch plugin is called cloudwatch_logs to prevent conflicts/confusion. Check the amazon repo for the Golang plugin for details on the deprecation/migration plan for the original plugin.
構成図
今回はECS Fargateで起動したApache httpdコンテナで検証してみました。
Terraformで構築する全体構成図は以下の通りです。
ログ出力の詳細
ログ出力の詳細な図となります。
各ログ毎の補足事項を、以下にまとめました。
httpd Log
FireLens経由で、CloudWatch LogsやKinesis Data Firehoseへログ出力します。
FireLensのFluent BitアプリにIAM権限が必要なため、ECSタスクロールに権限が必要です。
Fluent Bit Log
FireLensのFluent Bitコンテナ自身のログを、ログ配信の障害切り分けのために取得します。
awslogs
ログドライバーでCloudWatch Logsへログを送信するため、ECSタスク実行ロールに権限が必要です。
ちなみに今回の検証とは別に、awslogs
ログドライバーのみ使用する場合は、FireLensは不要となります。
Delivery Error log
Kinesis Data FirehoseからS3へ配信する際のエラーログとなります。
障害切り分けのために、Kinesis Data Firehoseのエラーログ記録を有効化します。
検証環境
今回実行した環境は以下の通りです。
Terraform関連
項目 | バージョン |
---|---|
macOS BigSur | 11.6 |
Terraform | 1.0.7 |
AWSプロバイダー | 3.63.0 |
コンテナ
項目 | バージョン |
---|---|
httpd | latest(2.4.51) |
amazon/aws-for-fluent-bit | latest(2.21.1) |
Fluent Bit | 1.8.9 |
Docker Desktop | 4.1.1 |
ざっくり設計方針
今回コードを書くにあたって、特に意識した方針は以下の通りです。
- 検証環境のコンテナのイメージタグはlatestタグとする。
- ※本番環境ではlatestタグの運用はトラブルの元なので避けましょう。ECRでイメージタグの上書き禁止も合わせて設定するのが良いです。
- FireLens用のイメージ作成とECRへプッシュも、Terraformの
null_resource
で実施する。 - httpd Log用のCloudWatch Logsのログストリーム作成は、Fluent Bitのカスタム設定ファイルで定義し、Fluent Bitから実施できるようにする。
- IAMロールにアタッチするIAMポリシーはFull権限を避け、必要最低現にする。
- ただし、Fluent Bitのカスタム設定ファイルによるログストリーム作成については、
Resources
は*
で定義し、イメージデプロイ担当者に裁量を持たせる。
- ただし、Fluent Bitのカスタム設定ファイルによるログストリーム作成については、
フォルダ構成
% tree . ├── aws_alb.tf ├── aws_cloudwatch.tf ├── aws_ecr.tf ├── aws_ecs.tf ├── aws_iam_ecs.tf ├── aws_iam_kinesis.tf ├── aws_kinesis.tf ├── aws_s3_alblog.tf ├── aws_s3_firelens.tf ├── aws_sg_alb.tf ├── aws_sg_ecs.tf ├── aws_vpc.tf ├── fluentbit │ ├── Dockerfile │ └── extra.conf ##Fluent Bitのカスタム設定ファイル ├── httpd │ └── container_definitions.json ##ECSコンテナ定義 ├── outputs.tf ├── provider.tf └── version.tf
Terrraformコード
各コードは折りたたんで記載してあります。
プロバイダー設定
provider.tf
provider "aws" { ##東京リージョン region = "ap-northeast-1" }
バージョン設定
version.tf
terraform { ## バージョンを固定 required_version = "1.0.7" }
VPC
aws_vpc.tf
# Terraform Registry # AWS VPC Terraform module module "vpc" { source = "terraform-aws-modules/vpc/aws" version = "3.10.0" name = "my-vpc" cidr = "10.0.0.0/16" enable_dns_hostnames = true enable_dns_support = true azs = ["ap-northeast-1a", "ap-northeast-1c"] public_subnets = ["10.0.11.0/24", "10.0.12.0/24"] #デフォルトセキュリティグループのルール削除 manage_default_security_group = true default_security_group_ingress = [] default_security_group_egress = [] }
ALB
aws_alb.tf
resource "aws_lb" "alb" { name = "inomaso-dev-alb" load_balancer_type = "application" internal = false idle_timeout = 60 # ALB削除保護無効 # 本番はtrue推奨 enable_deletion_protection = false security_groups = [aws_security_group.alb.id] subnets = module.vpc.public_subnets } resource "aws_alb_listener" "alb_http" { load_balancer_arn = aws_lb.alb.arn port = "80" protocol = "HTTP" default_action { target_group_arn = aws_lb_target_group.alb_tg.arn type = "forward" } } resource "aws_lb_target_group" "alb_tg" { name = "inomaso-dev-alb-tg" port = 80 protocol = "HTTP" vpc_id = module.vpc.vpc_id target_type = "ip" health_check { path = "/" port = "traffic-port" protocol = "HTTP" timeout = 5 interval = 10 healthy_threshold = 2 unhealthy_threshold = 2 matcher = 200 } }
aws_sg_alb.tf
resource "aws_security_group" "alb" { name = "inomaso-dev-alb-sg" description = "inomaso-dev-alb-sg" vpc_id = module.vpc.vpc_id ingress { from_port = 80 to_port = 80 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "inomaso-dev-alb-sg" } }
outputs.tf
output "alb_dns_name" { description = "ALB DNS Name" value = aws_lb.alb.dns_name }
ECR関連
fluentbit/Dockerfile
FROM amazon/aws-for-fluent-bit:latest COPY fluentbit/extra.conf /fluent-bit/etc/extra.conf
fluentbit/extra.conf
一点注意なのが、この方法でロググループを作成した場合は、Terraformのtfstateファイルの管理対象外となるため、terraform destroyしてもリソースは残り続けます。
[OUTPUT] Name cloudwatch_logs Match * region ap-northeast-1 log_group_name /ecs/firelens/httpd log_stream_prefix from- auto_create_group true log_retention_days 90 [OUTPUT] Name kinesis_firehose Match * region ap-northeast-1 delivery_stream inomaso-dev-kinesis-firehose
aws_ecr.tf
resource "aws_ecr_repository" "fluentbit" { name = "inomaso-dev-ecr-fluentbit" ## 同じタグを使用した後続イメージのプッシュによるイメージタグの上書き許可 image_tag_mutability = "MUTABLE" ## プッシュ時のイメージスキャン image_scanning_configuration { scan_on_push = true } } # AWSアカウント情報取得 data "aws_caller_identity" "my" {} # terraform apply時にFluent Bitのコンテナイメージプッシュ resource "null_resource" "fluentbit" { ## 認証トークンを取得し、レジストリに対して Docker クライアントを認証 provisioner "local-exec" { command = "aws ecr get-login-password --region ap-northeast-1 | docker login --username AWS --password-stdin ${data.aws_caller_identity.my.account_id}.dkr.ecr.ap-northeast-1.amazonaws.com" } ## Dockerイメージ作成 provisioner "local-exec" { command = "docker build -f fluentbit/Dockerfile -t inomaso-dev-fluentbit ." } ## ECRリポジトリにイメージをプッシュできるように、イメージにタグ付け provisioner "local-exec" { command = "docker tag inomaso-dev-fluentbit:latest ${aws_ecr_repository.fluentbit.repository_url}:latest" } ## ECRリポジトリにイメージをプッシュ provisioner "local-exec" { command = "docker push ${aws_ecr_repository.fluentbit.repository_url}:latest" } }
ECS
aws_ecs.tf
# タスク定義 resource "aws_ecs_task_definition" "task" { depends_on = [null_resource.fluentbit] family = "httpd-task" task_role_arn = aws_iam_role.ecs_task.arn execution_role_arn = aws_iam_role.ecs_task_exec.arn #0.25vCPU cpu = "256" #0.5GB memory = "512" network_mode = "awsvpc" requires_compatibilities = ["FARGATE"] ## templatefile関数でFluent BitのイメージがプッシュされたECRリポジトリURLを、imageurl変数で引き渡し container_definitions = templatefile("httpd/container_definitions.json", { imageurl = "${aws_ecr_repository.fluentbit.repository_url}:latest" }) } # クラスター resource "aws_ecs_cluster" "cluster" { name = "httpd-cluster" } # サービス resource "aws_ecs_service" "service" { #depends_on = [aws_cloudwatch_log_group.firelens] name = "httpd-service" cluster = aws_ecs_cluster.cluster.arn task_definition = aws_ecs_task_definition.task.arn desired_count = 2 launch_type = "FARGATE" platform_version = "1.4.0" health_check_grace_period_seconds = 60 network_configuration { assign_public_ip = true security_groups = [aws_security_group.ecs.id] subnets = module.vpc.public_subnets } ## ALBのターゲットグループに登録する、コンテナ定義のnameとportMappings.containerPortを指定 load_balancer { target_group_arn = aws_lb_target_group.alb_tg.arn container_name = "httpd" container_port = 80 } ## デプロイ毎にタスク定義が更新されるため、リソース初回作成時を除き変更を無視 lifecycle { ignore_changes = [task_definition] } }
httpd/container_definitions.json
[ { "essential": true, "name": "httpd", "image": "httpd:latest", "portMappings": [ { "protocol": "tcp", "containerPort": 80 } ], "memoryReservation": 100, "logConfiguration": { "logDriver": "awsfirelens" } }, { "essential": true, "name": "log_router", "image": "${imageurl}", "memoryReservation": 50, "logConfiguration": { "logDriver": "awslogs", "options": { "awslogs-group": "/ecs/firelens", "awslogs-region": "ap-northeast-1", "awslogs-stream-prefix": "httpd-sidecar" } }, "firelensConfiguration": { "type": "fluentbit", "options": { "config-file-type": "file", "config-file-value": "/fluent-bit/etc/extra.conf" } } } ]
aws_sg_ecs.tf
resource "aws_security_group" "ecs" { name = "inomaso-dev-ecs-sg" description = "inomaso-dev-ecs-sg" vpc_id = module.vpc.vpc_id ingress { from_port = 80 to_port = 80 protocol = "tcp" security_groups = [aws_security_group.alb.id] } egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] } tags = { Name = "inomaso-dev-ecs-sg" } }
aws_iam_ecs.tf
# ECSタスクロール作成 resource "aws_iam_role" "ecs_task" { name = "inomaso-dev-ecs-task-role" assume_role_policy = data.aws_iam_policy_document.ecs_task_assume.json } # ECSタスクロールを引き受けるための信頼関係を設定 data "aws_iam_policy_document" "ecs_task_assume" { statement { effect = "Allow" actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["ecs-tasks.amazonaws.com"] } } } # ECSタスクロール用のIAMポリシーを作成し、IAMロールにアタッチ resource "aws_iam_role_policy" "ecs_task" { name = "inomaso-dev-ecs-task-role-policy" role = aws_iam_role.ecs_task.id policy = data.aws_iam_policy_document.ecs_task_access.json } # ECSタスクロール用のIAMポリシーJSON data "aws_iam_policy_document" "ecs_task_access" { version = "2012-10-17" statement { sid = "CloudWatchLogsAccess" effect = "Allow" actions = [ "logs:CreateLogStream", "logs:CreateLogGroup", "logs:DescribeLogStreams", "logs:PutLogEvents", "logs:PutRetentionPolicy" ] resources = [ "*" ] } statement { sid = "FirehoseAccess" effect = "Allow" actions = [ "firehose:PutRecordBatch" ] resources = [ "${aws_kinesis_firehose_delivery_stream.firelens.arn}" ] } } # ECSタスク実行ロール作成 resource "aws_iam_role" "ecs_task_exec" { name = "inomaso-dev-ecs-task-exec-role" assume_role_policy = data.aws_iam_policy_document.ecs_task_exec_assume.json } # ECSタスク実行ロールを引き受けるための信頼関係を設定 data "aws_iam_policy_document" "ecs_task_exec_assume" { statement { effect = "Allow" actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["ecs-tasks.amazonaws.com"] } } } # ECSタスク実行ロールに既存IAMポリシーをアタッチ resource "aws_iam_role_policy_attachment" "ecs_task_exec" { role = aws_iam_role.ecs_task_exec.name policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonECSTaskExecutionRolePolicy" }
Kinesis Data Firehose
aws_kinesis.tf
resource "aws_kinesis_firehose_delivery_stream" "firelens" { name = "inomaso-dev-kinesis-firehose" destination = "s3" s3_configuration { role_arn = aws_iam_role.firehose.arn bucket_arn = aws_s3_bucket.firelens.arn buffer_size = 1 buffer_interval = 60 compression_format = "GZIP" cloudwatch_logging_options { enabled = "true" log_group_name = aws_cloudwatch_log_group.firelens.name log_stream_name = "kinesis_error" } } }
aws_iam_kinesis.tf
# firehose用IAMロール作成 resource "aws_iam_role" "firehose" { name = "inomaso-dev-firehose-role" assume_role_policy = data.aws_iam_policy_document.firehose_assume.json } # firehose用IAMロールを引き受けるための信頼関係を設定 data "aws_iam_policy_document" "firehose_assume" { statement { effect = "Allow" actions = ["sts:AssumeRole"] principals { type = "Service" identifiers = ["firehose.amazonaws.com"] } } } # firehose用IAMロール用のIAMポリシーを作成し、IAMロールにアタッチ resource "aws_iam_role_policy" "firehose" { name = "inomaso-dev-firehose-role-policy" role = aws_iam_role.firehose.id policy = data.aws_iam_policy_document.firehose_access.json } # firehose用IAMロールのIAMポリシーJSON data "aws_iam_policy_document" "firehose_access" { version = "2012-10-17" statement { sid = "S3Access" effect = "Allow" actions = [ "s3:AbortMultipartUpload", "s3:GetBucketLocation", "s3:GetObject", "s3:ListBucket", "s3:ListBucketMultipartUploads", "s3:PutObject" ] resources = [ "arn:aws:s3:::${aws_s3_bucket.firelens.bucket}", "arn:aws:s3:::${aws_s3_bucket.firelens.bucket}/*", ] } statement { sid = "CloudWatchLogsDeliveryErrorLogging" effect = "Allow" actions = [ "logs:PutLogEvents" ] resources = [ "${aws_cloudwatch_log_group.firelens.arn}:log-stream:*" ] } }
CloudWatch Logs
aws_cloudwatch.tf
resource "aws_cloudwatch_log_group" "firelens" { name = "/ecs/firelens" #retention_in_days = 90 } resource "aws_cloudwatch_log_stream" "kinesis" { name = "kinesis_error" log_group_name = aws_cloudwatch_log_group.firelens.name }
S3
aws_s3_firelens.tf
resource "aws_s3_bucket" "firelens" { ## bucketを指定しないと[terraform-xxxx]というバケット名になる bucket = "inomaso-dev-firelens" acl = "private" ## S3バケットにオブジェクトがあっても削除 ## 本番はfalse推奨 force_destroy = true ## SSE-S3で暗号化 server_side_encryption_configuration { rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } } lifecycle_rule { id = "Delete-After-90days" enabled = true expiration { days = 90 } } } resource "aws_s3_bucket_public_access_block" "firelens" { bucket = aws_s3_bucket.firelens.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true }
ログ出力の確認
httpd Log
ECSで実行中のタスク数だけ、ログストリームが作成されてることを確認できました。
またALBからのヘルスチェックのリクエストログを確認できました。
{ "source": "stdout", "log": "10.0.12.144 - - [07/Nov/2021:09:14:02 +0000] \"GET / HTTP/1.1\" 200 45", "container_id": "5c5c639cc38042649f84d55c5df013dd-3386804179", "container_name": "httpd", "ecs_cluster": "httpd-cluster", "ecs_task_arn": "arn:aws:ecs:ap-northeast-1:[AccountID]:task/httpd-cluster/5c5c639cc38042649f84d55c5df013dd", "ecs_task_definition": "httpd-task:72" }
S3にもログが出力されていることを確認できました。
S3 Selectでログの内容をクエリーしてみました。
{ "log": "10.0.12.144 - - [07/Nov/2021:09:13:52 +0000] \"GET / HTTP/1.1\" 200 45", "container_id": "5c5c639cc38042649f84d55c5df013dd-3386804179", "container_name": "httpd", "source": "stdout", "ecs_cluster": "httpd-cluster", "ecs_task_arn": "arn:aws:ecs:ap-northeast-1:[AccountID]:task/httpd-cluster/5c5c639cc38042649f84d55c5df013dd", "ecs_task_definition": "httpd-task:72" }
Fluent Bit Log
httpdのサイドカーとして起動しているので、ECSで実行中のタスク数だけ、ログストリームが作成されてることを確認できました。
ログの内容も確認できました。
info
レベルのログのみなので、特に問題がないことがわかります。
Delivery Error log
FireLensとは直接関係ないですが、Kinesis Data Firehoseのエラーログも見ていきます。 ロググループとログストリームは事前に作成した通りに作成されていることを確認できました。
特に問題なければエラーログは作成されないので、試しにIAMポリシーのresources
を存在しないS3バケットに変更してみます。
data "aws_iam_policy_document" "firehose_access" { version = "2012-10-17" statement { sid = "S3Access" effect = "Allow" actions = [ "s3:AbortMultipartUpload", "s3:GetBucketLocation", "s3:GetObject", "s3:ListBucket", "s3:ListBucketMultipartUploads", "s3:PutObject" ] resources = [ "arn:aws:s3:::${aws_s3_bucket.firelens.bucket}:errortest", "arn:aws:s3:::${aws_s3_bucket.firelens.bucket}:errortest/*", ] } statement { sid = "CloudWatchLogsDeliveryErrorLogging" effect = "Allow" actions = [ "logs:PutLogEvents" ] resources = [ "${aws_cloudwatch_log_group.firelens.arn}:log-stream:*" ] } }
少し時間をまってからエラーログを確認したところ、以下のログが増えていました。
{ "deliveryStreamARN": "arn:aws:firehose:ap-northeast-1:[AccountID]:deliverystream/inomaso-dev-kinesis-firehose", "destination": "arn:aws:s3:::inomaso-dev-firelens", "deliveryStreamVersionId": 1, "message": "Access was denied. Ensure that the trust policy for the provided IAM role allows Firehose to assume the role, and the access policy allows access to the S3 bucket.", "errorCode": "S3.AccessDenied" }
検証中のFAQ
1. ECSでのログ保存にFireLensは必須なのか?
ECSからのログ出力先がCloudWatch Logsのみで問題なければ、FireLensを無理に使う必要はありません。 ログ出力先にCloudWatch Logs以外も選択したい場合や、ログのフィルタリングをしたい場合にFireLensを検討するのが良いかと思います。
2. FireLens経由でS3バケットへログ保存するのにKinesis Data Firehoseは必要なの?
FireLensのFluent Bitコンテナが予期せぬ停止をしてしまうと、送信頻度によってはログを消失する可能性があります。 Kinesis Data Firehoseを間に挟むことでログのバッファリングが可能なので、高頻度の送信によるニアリアルタイムでログを保管することができます。
3. FireLensとKinesis Data Firehoseの障害切り分け用のロググループも自動作成できなかったの?
awslogs
の設定で、LogConfiguration
のawslogs-create-group: true
を設定すれば可能です。
ただし、タスク実行ロールにlogs:CreateLogGroup
等の権限が追加で必要となります。
タスク実行ロールのデフォルトIAMポリシーであるAmazonECSTaskExecutionRolePolicy
には、上記権限は含まれていません。
今回はタスク実行ロールに余計な権限を与えたくなかったので、Terraformによりロググループを作成することにしました。
4. FireLensの新プラグインだとJSONに順序性がなくなる
プラグインのGithubのissueにもありましたが、JSONはキーの順序付けの概念をサポートしていないようです。 また、Qiitaの記事にも以下のような記載がありました。
JSON に限らずキー、バリューで表現されるデータ形式は順番を保証しないと考えるのがベターようです。
ログの確認はCloudWatch LogsのフィルターやAthenaを使用することになりますが、キーバリューによる検索なので、特に問題はないかと思います。
5. Kinesis Data Firehoseにてエラーログの記録を有効にしたけどログストリームが作成されない
Terraformでエラーログ保存用のロググループまで作成したのですが、ログストリームが自動作成されませんでした。 調べてみたところ弊社のブログに以下の記載があることを見つけました。
Kinesis Data Firehoseにてエラーログの記録を有効にする場合かつ、マネジメントコンソール以外で配信ストリームを作成する場合は、ロググループ、ログストリームを事前に作成する必要があります。
特にログストリームも自動で作成されないのは注意が必要ですね。
まとめ
FireLensの検証はタスク定義方法や、ECSのタスクロールやタスク実行ロールの権限で大分時間がかかってしまいました。 ただ苦労した分、ECSのログ出力についてだいぶ理解することができたので、案件対応にも活かすことができそうです。
この記事が、どなたかのお役に立てば幸いです。それでは!